[READ-ONLY] a fast, modern browser for the npm registry
at main 223 lines 7.4 kB view raw
1import * as v from 'valibot' 2import { PackageRouteParamsSchema } from '#shared/schemas/package' 3import type { 4 PackageAnalysis, 5 ExtendedPackageJson, 6 TypesPackageInfo, 7 CreatePackageInfo, 8} from '#shared/utils/package-analysis' 9import { 10 analyzePackage, 11 getTypesPackageName, 12 getCreatePackageName, 13 hasBuiltInTypes, 14} from '#shared/utils/package-analysis' 15import { 16 getDevDependencySuggestion, 17 type DevDependencySuggestion, 18} from '#shared/utils/dev-dependency' 19import { 20 NPM_REGISTRY, 21 CACHE_MAX_AGE_ONE_DAY, 22 ERROR_PACKAGE_ANALYSIS_FAILED, 23} from '#shared/utils/constants' 24import { parseRepoUrl } from '#shared/utils/git-providers' 25import { encodePackageName } from '#shared/utils/npm' 26import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta' 27 28interface AnalysisPackageJson extends ExtendedPackageJson { 29 readme?: string 30} 31 32export default defineCachedEventHandler( 33 async event => { 34 // Parse package name and optional version from path 35 // e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0" 36 const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 37 38 const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 39 40 try { 41 const { packageName, version } = v.parse(PackageRouteParamsSchema, { 42 packageName: rawPackageName, 43 version: rawVersion, 44 }) 45 46 // Fetch package data 47 const encodedName = encodePackageName(packageName) 48 const versionSuffix = version ? `/${version}` : '/latest' 49 const pkg = await $fetch<AnalysisPackageJson>( 50 `${NPM_REGISTRY}/${encodedName}${versionSuffix}`, 51 ) 52 53 // Only check for @types package if the package doesn't ship its own types 54 let typesPackage: TypesPackageInfo | undefined 55 if (!hasBuiltInTypes(pkg)) { 56 const typesPkgName = getTypesPackageName(packageName) 57 typesPackage = await fetchTypesPackageInfo(typesPkgName) 58 } 59 60 // Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app) 61 // Only show if the packages are actually associated (same maintainers or same org) 62 const createPackage = await findAssociatedCreatePackage(packageName, pkg) 63 64 const analysis = analyzePackage(pkg, { typesPackage, createPackage }) 65 const devDependencySuggestion = getDevDependencySuggestion(packageName, pkg.readme) 66 67 return { 68 package: packageName, 69 version: pkg.version ?? version ?? 'latest', 70 devDependencySuggestion, 71 ...analysis, 72 } satisfies PackageAnalysisResponse 73 } catch (error: unknown) { 74 handleApiError(error, { 75 statusCode: 502, 76 message: ERROR_PACKAGE_ANALYSIS_FAILED, 77 }) 78 } 79 }, 80 { 81 maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes 82 swr: true, 83 getKey: event => { 84 const pkg = getRouterParam(event, 'pkg') ?? '' 85 return `analysis:v2:${pkg.replace(/\/+$/, '').trim()}` 86 }, 87 }, 88) 89 90/** 91 * Fetch @types package info including deprecation status using fast-npm-meta. 92 * Returns undefined if the package doesn't exist. 93 */ 94async function fetchTypesPackageInfo(packageName: string): Promise<TypesPackageInfo | undefined> { 95 const result = await getLatestVersion(packageName, { metadata: true, throw: false }) 96 if ('error' in result) { 97 return undefined 98 } 99 return { 100 packageName, 101 deprecated: result.deprecated, 102 } 103} 104 105/** Package metadata needed for association validation */ 106interface PackageWithMeta { 107 maintainers?: Array<{ name: string }> 108 repository?: { url?: string } 109 deprecated?: string 110} 111 112/** 113 * Get all possible create-* package name patterns for a given package. 114 * e.g., "next" -> ["create-next", "create-next-app"] 115 * e.g., "@scope/foo" -> ["@scope/create-foo", "@scope/create-foo-app"] 116 */ 117function getCreatePackageNameCandidates(packageName: string): string[] { 118 const baseName = getCreatePackageName(packageName) 119 return [baseName, `${baseName}-app`] 120} 121 122/** 123 * Find an associated create-* package by trying multiple naming patterns using batch API. 124 * Returns the first associated package found (preferring create-{name} over create-{name}-app). 125 */ 126async function findAssociatedCreatePackage( 127 packageName: string, 128 basePkg: ExtendedPackageJson, 129): Promise<CreatePackageInfo | undefined> { 130 const candidates = getCreatePackageNameCandidates(packageName) 131 132 // Use batch API to fetch all candidates in a single request 133 const results = await getLatestVersionBatch(candidates, { metadata: true, throw: false }) 134 135 // Process results in order (first valid match wins) 136 for (let i = 0; i < candidates.length; i++) { 137 const result = results[i] 138 const candidateName = candidates[i] 139 if (!result || !candidateName || 'error' in result) continue 140 141 // Need to fetch full package data for association validation (maintainers/repo) 142 const createPkgInfo = await fetchCreatePackageForValidation( 143 candidateName, 144 basePkg, 145 result.deprecated, 146 ) 147 if (createPkgInfo) { 148 return createPkgInfo 149 } 150 } 151 152 return undefined 153} 154 155/** 156 * Fetch create-* package metadata for association validation. 157 * Returns CreatePackageInfo if the package is associated with the base package. 158 */ 159async function fetchCreatePackageForValidation( 160 createPkgName: string, 161 basePkg: ExtendedPackageJson, 162 deprecated: string | undefined, 163): Promise<CreatePackageInfo | undefined> { 164 try { 165 const encodedName = encodePackageName(createPkgName) 166 // Fetch /latest to get maintainers and repository for association validation 167 const createPkg = await $fetch<PackageWithMeta>(`${NPM_REGISTRY}/${encodedName}/latest`) 168 169 // Validate that the packages are actually associated 170 if (!isAssociatedPackage(basePkg, createPkg)) { 171 return undefined 172 } 173 174 return { 175 packageName: createPkgName, 176 deprecated, 177 } 178 } catch { 179 return undefined 180 } 181} 182 183/** 184 * Check if two packages are associated (share maintainers or same repo owner). 185 */ 186function isAssociatedPackage( 187 basePkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } }, 188 createPkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } }, 189): boolean { 190 const baseMaintainers = new Set(basePkg.maintainers?.map(m => m.name.toLowerCase()) ?? []) 191 const createMaintainers = createPkg.maintainers?.map(m => m.name.toLowerCase()) ?? [] 192 const hasSharedMaintainer = createMaintainers.some(name => baseMaintainers.has(name)) 193 194 return ( 195 hasSharedMaintainer || 196 hasSameRepositoryOwner(basePkg.repository?.url, createPkg.repository?.url) 197 ) 198} 199 200/** 201 * Check if two repository URLs have the same owner (works with any git provider). 202 */ 203function hasSameRepositoryOwner( 204 baseRepoUrl: string | undefined, 205 createRepoUrl: string | undefined, 206): boolean { 207 if (!baseRepoUrl || !createRepoUrl) return false 208 209 const baseRef = parseRepoUrl(baseRepoUrl) 210 const createRef = parseRepoUrl(createRepoUrl) 211 212 if (!baseRef || !createRef) return false 213 if (baseRef.provider !== createRef.provider) return false 214 if (baseRef.host && createRef.host && baseRef.host !== createRef.host) return false 215 216 return baseRef.owner.toLowerCase() === createRef.owner.toLowerCase() 217} 218 219export interface PackageAnalysisResponse extends PackageAnalysis { 220 package: string 221 version: string 222 devDependencySuggestion: DevDependencySuggestion 223}